Skip to content
created by Aha00aAha00a at 2026-05-26
last modified by Aha00aAha00a at 2026-10-11
revision: 13

Dev WebSocket 구현 정리

이 문서는 현재 AhaWiki에 구현된 WebSocket 기능(페이지 뷰 화면의 실시간 커서 공유 + 페이지 갱신 알림)을 코드 기준으로 정리합니다.

1. 엔드포인트와 연결 URL

  • 서버 라우트:
    • GET /ws/w/*nameEncoded → controllers.WikiRealtime.watch(nameEncoded: String).
  • 클라이언트는 위키 뷰 페이지에서 현재 경로(/w/...)를 /ws/w/...로 치환해 WebSocket URL을 만들고,
    • https면 wss://, 그 외에는 ws://를 사용합니다.

즉, 사용자가 문서 보기 화면에 진입하면 브라우저 JS가 해당 문서용 WebSocket에 자동 연결을 시도합니다. 옛 리비전(?revision=N 이 최신이 아닐 때)을 보는 화면은 예외로, 연결하지 않습니다 — 까닭은 Dev Page 옛 리비전 화면.

2. 서버 측 구조 (controllers.WikiRealtime + logics.PageCursorHub)

2026-08-10 에 Wiki 컨트롤러를 관심사별로 나누면서 watch 는 WikiRealtime 으로, 허브는 logics.PageCursorHub 로 갔다. 발행은 controllers.Wiki.save 에 남았고, 지금은 리비전을 쓰는 다른 경로들도 같은 자리를 통해 발행한다(아래 발행하는 경로).

2.1. Room 키

  • 페이지 단위 room 키는 wiki:<siteId>:<pageId> 형식입니다.
  • 멀티 사이트 환경에서 siteId + pageId 조합으로 같은 이름의 문서를 분리합니다.

2.2. 메모리 허브: PageCursorHub

PageCursorHub는 서버 메모리 내 구독자 맵을 가지고 브로드캐스트를 수행합니다.

  • 저장 구조
    • subscribers: TrieMap[roomKey, TrieMap[connectionId, PageSubscriber]]
    • PageSubscriber는 queue(outbound queue)와 saveSenderId를 가짐.
  • 동작
    • subscribe: room에 연결 추가
    • unsubscribe: 연결 제거 및 queue complete
    • broadcast: 발신자(connectionId) 제외하고 메시지 전송
    • setSaveSenderId: 연결별 저장용 식별자 저장
    • broadcastPageUpdated: 동일 saveSenderId 연결은 제외하고 page.updated 전송

이 허브는 프로세스 메모리에 있고 인스턴스 사이에 직접 동기화가 없습니다. 2026-10-11 부터 운영은 평소 한 대라 아래 문제는 배포 중 둘이 함께 뜨는 동안에만 생깁니다(Dev Deploying «평소에는 한 대, 배포 때만 두 대»). 인스턴스 둘이 동시에 요청을 받을 때는 커서·접속자 표시는 저장한 요청과 같은 인스턴스에 소켓이 붙은 사람에게만 갑니다. page.updated 는 예외로, 2026-09-15 부터 Redis pub/sub 로 인스턴스 사이에 전달됩니다 — 그래서 새로고침 토스트와 Kanban 자동 동기화는 어느 인스턴스에 붙어 있든 옵니다(아래 인스턴스 사이 실시간 이벤트 절). 커서는 여전히 인스턴스 안에만 있고(고빈도라 그대로 둠), 놓친 커서·접속자 상태는 소켓이 다시 열릴 때의 wiki:ws.connected 재동기화로 따라잡습니다.

2.3. 핸드셰이크/권한 확인 (watch)

watch(nameEncoded)에서 수행하는 핵심 로직:

  • nameEncoded를 decode해 페이지명 복원
  • 현재 host 기준 사이트(Site) 조회
  • 페이지 최신 리비전 컨텐츠를 기준으로 읽기 권한(WikiPermission.isReadable) 검사
  • 권한 없으면 Forbidden("Permission denied.") 반환
  • 권한 있으면 WebSocket Flow를 생성

이로 인해 읽기 권한이 없는 페이지는 소켓 연결 자체가 거부됩니다.

2.4. 메시지 처리 (서버가 받는 타입)

서버 sink는 inbound JSON의 type에 따라 아래를 처리합니다.

2.4.1. cursor.move

  • 입력: { type: "cursor.move", x, y }
  • x, y는 숫자로 읽고 각각 [0.0, 1.0] 범위로 clamp
  • 서버가 재포장하여 room에 브로드캐스트:
    • type, siteId, pageId, senderId, x, y, ts

2.4.2. cursor.hello

  • 입력: { type: "cursor.hello", saveSenderId? }
  • saveSenderId를 연결 상태에 저장
  • 현재 연결의 senderId, 사용자 nickname, profileImageUrl을 담은 hello를 타 사용자에게 브로드캐스트

2.4.3. cursor.ping

  • 입력: { type: "cursor.ping" }
  • 클라이언트의 keep-alive. 서버는 받고 아무 일도 하지 않는다.

2.4.4. 그 외 타입

  • 무시 (no-op)

2.5. 연결 시/종료 시 동작

  • 연결 직후(mapMaterializedValue)
    • room 구독 등록
    • 자기 자신 queue에 cursor.hello 1회 push
    • 타 구독자에게도 cursor.hello 브로드캐스트
  • 연결 종료(watchTermination)
    • room에서 unsubscribe

3. 저장 시 페이지 갱신 이벤트 (page.updated)

새 리비전을 쓰는 경로는 저장이 성공한 뒤 다음 payload를 만들어 브로드캐스트합니다.

  • type: "page.updated"
  • pageName
  • revision (새 리비전)
  • editorNickname
  • dateInserted

전송 방식은 CrossInstanceBus.publishPageUpdated(siteSeq, pageName, revision, editorNickname, dateInserted, saveSenderId) 하나입니다. room 키와 위 payload는 그 안에서 만듭니다(CrossInstanceBus.pageUpdatedPayload) — 호출부마다 payload를 조립하면 필드 하나가 한쪽에서만 바뀌는 날이 옵니다.

  • 요청 폼으로 넘어온 saveSenderId와 동일한 saveSenderId를 가진 구독자에게는 알림을 제외합니다.
  • 목적: 저장을 유발한 동일 클라이언트(혹은 동일 브라우저 탭 그룹)에게 중복 "새로고침 알림"을 줄이는 것.

3.1. 발행하는 경로

  • controllers.Wiki.save — 웹 편집기·Kanban 저장. saveSenderId를 함께 보내므로 저장한 탭은 제외됩니다.
  • controllers.ApiV1.savePage — API Key 저장(Api). 2026-09-25에 추가했습니다.
  • controllers.Diary.write — 오늘 날짜 페이지에 한 줄 덧붙이기. 2026-09-25에 추가했습니다.
  • controllers.WikiSpreadsheet.syncGoogleSpreadsheet — 스프레드시트를 표로 끌어오기. 2026-09-25에 추가했습니다.

뒤 셋은 saveSenderId가 없습니다. 저장한 쪽이 그 페이지를 보고 있는 브라우저가 아니기 때문입니다(스크립트이거나, 저장 뒤 그 페이지로 이동합니다). 2026-09-25 전에는 이 셋이 아예 발행하지 않아서, 봇이 고친 페이지를 열어 둔 사람은 소켓이 다시 열릴 때까지 낡은 화면을 봤습니다.

rename 은 발행하지 않습니다. 원래 이름 자리에 남는 것은 #!redirect 한 줄짜리 리비전 1 이라, 「누가 이 페이지를 고쳤습니다, 새로고침할까요」 토스트를 띄우면 새로고침이 리다이렉트로 떨어집니다. 이동은 갱신과 다른 사건이고, 알리려면 제 타입이 있어야 합니다.

4. 클라이언트 측 동작 (view.scala.html)

문서 보기 페이지 JS가 WebSocket을 담당합니다.

4.1. 연결/재연결

  • connect()에서 new WebSocket(wsUrl) 생성
  • onopen 시 saveSenderId를 생성 후 cursor.hello 전송 (브라우저 쿠키 ID + 탭별 sessionStorage ID 조합)
  • saveSenderId는 브라우저 공통 ID(쿠키) + 탭 고유 ID(sessionStorage)를 결합해, 같은 브라우저의 다른 탭은 서로 다른 값이 되도록 합니다. 쿠키 수명은 view.scala.html 의 senderIdCookieMaxAgeSeconds(현재 1년)입니다. 같은 쿠키를 edit.page.js(cookieMaxAgeSeconds)와 AhaWiki.Kanban.js(SENDER_ID_COOKIE_MAX_AGE_SECONDS)도 같은 값으로 다시 씁니다 — 한 곳만 바꾸면 다른 곳이 되돌립니다.
  • onopen 은 wiki:ws.connected DOM CustomEvent(detail.pageName)도 발행합니다. Kanban 이 이것을 받아 끊긴 사이 놓친 저장을 따라잡습니다 — Dev Kanban Realtime 참고.
  • onopen 뒤 30초마다 cursor.ping 을 보냅니다(간격은 view.scala.html 의 setInterval 인자). onclose 에서 그 타이머를 지웁니다.
  • onclose 시 재연결한다 — 어떻게는 열렸던 소켓인지에 달렸다. 열렸다가 끊긴 소켓(idle timeout, 배포)은 reconnectDelayMs(1.5초) 뒤에 다시 연다. 한 번도 안 열린 소켓은 핸드셰이크에서 거절된 것이다 — 서버가 읽을 수 없는 페이지에 403 을 답하면 브라우저에는 close 로만 보인다 — 이때는 지연을 두 배씩 늘리며(1.5·3·6·12초) 다섯 번째 거절에서 그만둔다(handshakeGiveUpAfter). 열리면 그 횟수는 0 으로 돌아간다. 2026-09-26 전에는 구분 없이 1.5초마다 무한히 다시 열었고, 8월에 한 주소가 44,249번 두드렸다(Dev Logs). 판정 함수는 test/wiki-view.reconnect.test.mjs 가 템플릿에서 그대로 읽어 검사한다.
  • onerror/onclose는 console warn 로그 출력

4.2. 수신 메시지 처리

4.2.1. cursor.hello

  • senderId를 기준으로 상대 커서 메타(nickname, profileImageUrl) 저장
  • 본인 senderId 확정 로직 수행
  • 새 상대를 처음 인지하면 hello ack 성격으로 cursor.hello를 다시 보냄

4.2.2. cursor.move

  • upsertCursor(senderId, x, y) 호출
  • 화면 크기 기준 좌표로 변환 후 커서 DOM 갱신 대상에 반영
  • 별도 렌더 루프(requestAnimationFrame)가 보간(smoothing) 이동 처리

4.2.3. page.updated

  • revision이 유효하고, 마지막으로 본 revision과 다를 때만 처리
  • ${editorNickname} updated this page. Would you like to refresh? 토스트 노출
  • 중복 revision은 무시

4.3. 송신 메시지 처리

  • 마우스 이동 이벤트에서 50ms throttle로 cursor.move 전송
  • 좌표는 e.pageX / window.width, e.pageY / window.height로 정규화하여 [0,1] 범위로 보냄

5. 현재 프로토콜 요약

클라이언트 → 서버:

  • cursor.hello
{ "type": "cursor.hello", "saveSenderId": "..." }
  • cursor.move
{ "type": "cursor.move", "x": 0.42, "y": 0.77 }
  • cursor.ping (30초마다)
{ "type": "cursor.ping" }

서버 → 클라이언트:

  • cursor.hello
{ "type": "cursor.hello", "senderId": "...", "nickname": "...", "profileImageUrl": "..." }
  • cursor.move
{ "type": "cursor.move", "siteId": 1, "pageId": "Home", "senderId": "...", "x": 0.42, "y": 0.77, "ts": 1710000000000 }
  • page.updated
{ "type": "page.updated", "pageName": "Home", "revision": 12, "editorNickname": "Alice", "dateInserted": "2026-05-11T10:20:30" }

6. 운영/구조상 유의사항

  • 허브가 인스턴스마다 따로라서 생기는 일은 위 «메모리 허브» 의 인용에 있습니다.
  • 큐 크기는 Source.queue[String](32, OverflowStrategy.dropHead)이므로 소비 지연 시 오래된 outbound 메시지가 버려질 수 있습니다.
    • 커서 위치 스트림 특성상 최신 상태 우선 정책으로 해석 가능합니다.
  • 읽기 권한은 소켓 연결 시점에 검사되며, 연결 후 권한 변경에 대한 즉시 강제 종료 로직은 별도로 보이지 않습니다.
  • Kanban 인터프리터 저장은 saveSenderId를 함께 전송합니다. 서버에서 409 Conflict를 반환하면 최신 revision을 재조회 후 자동 재시도하고, 모두 실패 시 non-blocking 토스트를 표시합니다 (페이지 강제 새로고침 없음). 재시도 횟수는 AhaWiki.Kanban.js 의 requestSaveKanban 안 attempt 상한(현재 3회)입니다.
  • page.updated 이벤트를 칸반 실시간 동기화에 활용합니다 — 자세한 내용은 Dev Kanban Realtime 참조.

7. 페이지 새로고침 알림 상세 (page.updated)

같은 페이지를 여러 사용자가 동시에 보고 있을 때, **다른 사용자의 저장**을 감지해 새로고침을 유도하는 기능입니다.

7.1. 서버 이벤트 발행

페이지 저장 성공 시점에 해당 페이지 room으로 page.updated 이벤트를 broadcast 합니다. 어느 경로가 발행하고 payload에 무엇이 담기는지는 위 저장 시 페이지 갱신 이벤트 에 한 번만 적어 둡니다.

7.2. 클라이언트 수신/처리

Wiki/view 페이지의 WebSocket onmessage에서 page.updated 이벤트를 처리합니다.

7.2.1. 공통 처리 (Kanban 이 없는 페이지)

  • 토스트 메시지: ${editorNickname} updated this page. Would you like to refresh?
  • 액션 버튼: Refresh → 클릭 시 window.location.reload() 실행
  • .InterpreterKanban 이 있는 페이지는 2초 기다렸다가 Kanban 동기화가 안 됐을 때만 띄운다 — Dev Kanban Realtime 의 토스트 억제 참고.

7.2.2. 추가 처리: wiki:page.updated DOM CustomEvent 발행

page.updated 수신 후 document.dispatchEvent(new CustomEvent('wiki:page.updated', { detail: { pageName, revision, editorNickname } })) 를 발행합니다.

이를 통해 페이지에 임베드된 컴포넌트(예: Kanban)가 자신의 업데이트 로직을 독립적으로 구독할 수 있습니다.

7.2.3. Kanban 페이지에서의 추가 동작

AhaWiki.Kanban.js가 wiki:page.updated 이벤트를 수신하면 보드를 자동으로 갱신합니다.

  • 500ms debounce 후 GET /w/{page}?action=raw 으로 최신 원문 fetch
  • 칸반 블록 위치 재계산 → parseKanbanText
  • 3-way 병합(base / local / server) 적용 → rerenderColumns
  • 사용자 입력 보호: 뮤테이션 대기 중이면 완료 후 재적용. 모달이 열린 카드는 객체 참조를 유지하고, 편집 중이 아닌 필드는 서버 값으로 바꾼다(편집 중인 필드만 로컬 값 유지 + 배너)

자세한 내용: Dev Kanban Realtime

7.3. 중복 알림 방지

  • 동일 revision에 대해서는 토스트를 1회만 표시
  • 이미 떠 있는 토스트의 중복 표시 방지

7.4. 동작 시나리오 (일반 위키 페이지)

  • 사용자 A와 B가 같은 페이지를 열어둡니다.
  • A가 페이지를 저장합니다.
  • 서버가 page.updated 이벤트를 페이지 room에 전송합니다.
  • B 화면에서 새로고침 안내 토스트가 표시됩니다.
  • B가 Refresh를 누르면 페이지가 새로고침되어 최신 revision을 확인할 수 있습니다.

7.5. 동작 시나리오 (Kanban 페이지)

  • 사용자 A와 B가 같은 Kanban 페이지를 열어둡니다.
  • A가 카드를 추가·이동·수정합니다.
  • 서버가 page.updated 이벤트를 전송합니다.
  • B의 view.scala.html이 wiki:page.updated DOM CustomEvent를 발행합니다.
  • B의 AhaWiki.Kanban.js가 이벤트를 수신 → 원문 fetch → 병합 → 보드 자동 갱신.
  • B는 새로고침 없이 A의 변경이 보드에 반영됨을 확인합니다.

7.6. 운영 확인 항목

  • 저장 당사자(sender) 제외 전송 보장 확인
  • watch 권한 모델과 이벤트 수신 범위 정합성 확인
  • 토스트 UX(입력 중 방해 최소화, 모바일/데스크톱 가독성) 점검
  • 운영 환경 WebSocket 에러/재연결 로그 모니터링

8. 인스턴스 사이 실시간 이벤트 (구현·배포됨 2026-09-15)

page.updated 를 Redis pub/sub 로 인스턴스 사이에 전달한다. 소유자가 «Redis pub/sub·page.updated 만» 으로 정해 구현했고, 2026-09-15 에 양쪽 인스턴스에 배포했다. 코드는 logics.CrossInstanceBus(신규) 와 controllers.Wiki.save 의 호출부, Module 의 eager 바인딩. 아래는 무엇을·왜 그렇게 했는지의 기록이다.

8.1. 문제

PageCursorHub 는 프로세스 메모리이고 운영은 인스턴스 둘이 요청을 나눠 받았다(2026-10-11 까지 — 지금은 평소 한 대, Dev Deploying). 그래서 한 인스턴스에서 일어난 저장의 page.updated 와 커서는 같은 인스턴스에 소켓이 붙은 사람에게만 간다. 다른 쪽 사람은 소켓이 다시 열릴 때의 wiki:ws.connected 재동기화로만 따라잡는다(Dev Kanban Realtime). 값이 큰 손실은 page.updated 다 — Kanban 실시간 동기화와 새로고침 알림이 절반의 독자에게 안 간다. 커서는 «편의» 라 손실이 가볍다.

8.2. 결정 1 — 무엇을 인스턴스 사이로 보낼까

  • page.updated — 저빈도(저장할 때만), 값 큼. → 인스턴스 사이로 보낸다.
  • cursor.move — 고빈도(사용자당 50ms throttle), 값 작음. → 인스턴스 안에만 둔다(권장). 이걸 브로커로 보내면 커서 한 번마다 전 인스턴스로 fan-out 되어 부하가 급증한다.
  • cursor.hello(접속자 표시) — 중간. 나중에 필요하면 추가.

권장: 우선 page.updated 만 인스턴스 사이로. 커서는 지금처럼 인스턴스 안에.

8.3. 결정 2 — 전달 특성

이벤트는 일시적이다. page.updated 는 클라이언트가 revision 으로 중복·역순을 무시하고, 커서는 last-write-wins 다. 그래서 at-most-once·순서 무보장·중복 허용 으로 충분하다 — durable queue 가 필요 없다. 브로커가 죽으면 인스턴스 사이 전달만 멈추고 «현재 동작(인스턴스 안)» 으로 degrade 한다. 재연결 catch-up 이 있으므로 정합성 손실은 없다.

8.4. 브로커 선택지

  • Redis pub/sub (권장). 캐시가 이미 공유 Redis 다(play-redis; ehcache 는 build.sbt 에서 주석). 두 인스턴스가 같은 Redis 를 보므로 공유 브로커가 이미 있다. 각 인스턴스가 채널 하나를 SUBSCRIBE 하고, 저장 시 로컬 broadcast + 채널에 PUBLISH 한다. 표준적이고 N 인스턴스로 확장되며 토폴로지 가정이 없다. 비용: play-redis 는 pub/sub 를 노출하지 않으므로 pub/sub 전용 연결(예: Jedis 의 blocking subscribe 스레드, 또는 Lettuce) 하나를 새로 둬야 한다.
  • 인스턴스 사이 HTTP relay. 두 인스턴스가 같은 호스트라(loopback 10001/10000) 저장 시 로컬 broadcast + 상대 포트의 내부 endpoint 로 POST → 상대가 로컬로 재broadcast. 새 의존성이 0 이고 loopback 이라 빠르고 믿을 만하다. 비용: 상대 목록(포트)을 설정으로 들고 있어야 하고, 인스턴스를 다른 호스트로 나누면 재작업이며, 내부 endpoint 를 loopback·allowlist 로 보호해야 한다(FilterAccessLog 의 ipWhitelist 처럼).
  • Pekko Cluster DistributedPubSub (기각). 클러스터 구성(seed·membership·split-brain)이 2 동일호스트에는 과하다.

권장: 1(Redis pub/sub). 이미 공유 Redis 가 있어 새 토폴로지 가정이 없고 표준적이다. 새 의존성(pub/sub 클라이언트) 하나가 유일한 비용. 새 의존성을 피하고 싶으면 2 도 지금의 «동일호스트 2 인스턴스» 구조에 잘 맞는다.

8.5. 구현 스케치 (Redis pub/sub 기준)

  • 새 지점 하나(CrossInstanceBus, 또는 PageCursorHub 확장):
    • publishPageUpdated(roomKey, saveSenderId, payload): 지금처럼 로컬 broadcastPageUpdated 를 부르고, 이어서 {roomKey, saveSenderId, payload, origin=instanceId} 를 채널에 PUBLISH.
    • 앱 시작 시 채널을 SUBSCRIBE → 수신 메시지의 origin 이 자기면 무시(로컬로 이미 보냄), 아니면 PageCursorHub.broadcastPageUpdated(roomKey, saveSenderId, payload) 만 호출(재PUBLISH 안 함).
  • instanceId 는 기동 시 UUID(또는 포트).
  • controllers.Wiki.save 의 호출부(현재 broadcastPageUpdated 직접 호출, Wiki.scala)를 publishPageUpdated 로 바꾼다.
  • saveSenderId 를 payload 에 실어 상대 인스턴스도 저장자의 다른 탭을 제외하게 한다(현재 로컬 제외 규칙과 동일).
  • 실패 격리: PUBLISH/SUBSCRIBE 에러는 로그만 남기고 로컬 동작은 그대로.

8.6. 정해진 결정 (2026-09-15)

  • 범위: page.updated 만. 커서는 인스턴스 안에 그대로 둔다(고빈도·편의).
  • 브로커: Redis pub/sub.
  • Redis 공유: 확인함 — 두 인스턴스가 같은 로컬 Redis(localhost:6379, db0)를 쓴다(캐시 Redis 그대로 재사용). 채널은 ahawiki:0:page.updated.
  • 새 의존성: Jedis 를 넣었다(play-redis 는 pub/sub 를 노출하지 않음). publish 는 JedisPooled, subscribe 는 데몬 스레드에서 blocking subscribe + 끊기면 2초 후 재접속.

8.7. 구현 결과 / 검증

  • 실패 격리: PUBLISH/SUBSCRIBE 오류는 로그만 남기고 로컬 동작은 유지 — Redis 가 죽으면 «인스턴스 안» 으로 degrade. 테스트 모드에선 버스를 끈다(Redis 없음).
  • 단위: CrossInstanceBusUnit 가 전송 형식(encode/decode 왕복, saveSenderId 유무, 잘못된 입력)을 덮는다. Scala 258 통과.
  • 수동(canary→양쪽): 두 인스턴스가 채널을 SUBSCRIBE(PUBSUB NUMSUB = 2) 하고, 채널에 넣은 page.updated 하나가 10001·10000 양쪽에 붙은 소켓에 모두 도달함을 확인. publish 는 Wiki.save 에 연결되어 있고 로그로 «CrossInstanceBus on» 확인.
  • 부하: page.updated 는 저빈도라 Redis 부하는 무시할 만.

8.8. 남아 있던 구멍 (2026-09-25 메움)

배포 당시 발행하는 곳은 Wiki.save 하나였다. API·Diary·Spreadsheet 저장은 리비전을 쓰면서도 아무에게도 알리지 않았다 — 인스턴스 사이가 아니라 같은 인스턴스 안에서도 안 갔다. 위키 문서 sync 가 API 로 도는 지금은 그게 가장 흔한 저장이라, 봇이 고친 페이지를 열어 둔 사람만 낡은 화면을 보고 있었다.

세 경로에 발행을 넣으면서 payload 조립을 CrossInstanceBus 안으로 옮겼다. 호출부가 넷이 되는 순간 «같은 JSON 을 네 군데서 만든다» 가 되고, 그중 하나만 고쳐지는 날이 온다. 지금은 호출부가 한 줄씩이다. 단위테스트 CrossInstanceBusUnit 가 payload 필드와 타입(특히 revision 이 문자열이 아니라 수)을 덮는다.

9. See Also

9.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • 46.73% Dev Page page(68:42), wiki(29:19), updated(43:3), 페이지(20:20), kanban(25:7), 같은(14:10), revision(7:15), redis(19:1), name(9:11), dev(11:8)
  • 35.30% Dev Kanban Realtime page(68:18), kanban(25:51), updated(43:12), cursor(41:2), id(34:8), wiki(29:10), columns(1:30), sender(29:1), save(27:3), 서버(10:20)
  • 33.53% ToDo page(68:37), 같은(14:62), wiki(29:30), 페이지(20:31), updated(43:6), dev(11:38), 다시(7:38), cursor(41:1), id(34:4), 으로(8:28)
  • 33.27% Dev Telegram page(68:13), id(34:22), wiki(29:10), save(27:5), 페이지(20:9), url(5:15), 저장(15:4), name(9:10), 처리(14:4), 연결(15:2)

9.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+